package com.emotion.recognition.server.som;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;

import com.emotion.recognition.shared.Emotion;
import com.google.common.base.Preconditions;

public class SOM {
    private final double initialLearningRate;
    private double learningRate;
    private final int inputSize;
    private final int maxEpochs;
    private final FeatureMap featureMap;

    public SOM(int inputSize, int mapSize, double learningRate, int maxEpochs, double radius) {
        Preconditions.checkArgument(0 < learningRate && learningRate <= 1,
                "Learning rate must be in the range (0, 1]");
        Preconditions.checkArgument(0 < maxEpochs, "Max training epochs must be at least one");
        Preconditions.checkArgument(2 < mapSize, "Map size must be at least three");
        Preconditions.checkArgument(0 < inputSize, "Input size must be at least one");

        featureMap = new FeatureMap(mapSize, inputSize, radius);

        this.inputSize = inputSize;
        this.initialLearningRate = learningRate;
        this.learningRate = learningRate;
        this.maxEpochs = maxEpochs;
    }

    /**
     * 
     * Test input without running algorithm. Simply insert into the graph.
     * 
     * @param input
     * @param fileName
     * @return map showing odds of being each emotion
     */
    public Map<Emotion, Double> testOneInput(List<Double> input, String fileName) {
        FeatureNode bmu = getBMU(input, fileName);

        debugPrint();

        // In order to be able to quantify how well the algorithm is doing, we use an algorithm to
        // determine what emotion it likely is.
        return determineEmotionByNeighbourhood(bmu);

    }

    public void classify(List<List<Double>> inputs, List<String> categories) {
        Preconditions.checkArgument(0 < inputs.size(), "Input size must be at least one");

        Preconditions.checkArgument(inputs.size() == categories.size(),
                "Number of inputs-categories pair does not match.");

        for (List<Double> input : inputs) {
            Preconditions.checkArgument(input.size() == this.inputSize,
                    "Input dimensions do not match");
        }

        Random generator = new Random();

        for (int i = 0; i < this.maxEpochs; i++) {
            // Recalculate the radius.
            featureMap
                    .setRadius(SomMath.calculateRadius(featureMap.getMapSize() / 2, i, maxEpochs));

            // debugPrint();

            // Randomly select an input.
            int index = generator.nextInt(inputs.size());

            // Run the algorithm on input.
            classifyOneInput(inputs.get(index), categories.get(index));

            // Decrease learning rate
            learningRate = SomMath.calculateLearningRate(initialLearningRate, i, maxEpochs);
        }

        debugPrint();
    }

    private FeatureNode getBMU(List<Double> input, String category) {
        FeatureNode minNode = null;
        double minDistance = Double.MAX_VALUE;
        double currentDistance = 0;

        for (int i = 0; i < featureMap.getMapSize(); i++) {
            for (int j = 0; j < featureMap.getMapSize(); j++) {
                // Calculate distance from input to each node.
                currentDistance = SomMath.calculateDistance(input, featureMap.nodes[i][j]);

                // Minimum distance becomes BMU.
                if (currentDistance < minDistance) {
                    minDistance = currentDistance;
                    minNode = featureMap.nodes[i][j];
                }
            }
        }

        Preconditions.checkArgument(minNode != null, "Error: No minimum node was found");

        // Used for debugging purposes. See what node contains which values.
        minNode.setValue(category);

        return minNode;
    }

    /**
     * For testing images in a node, we look at its Manhattan distance to nodes around it and the
     * concentration of an emotion to determine which emotion it likely is.
     * 
     * @param bmu
     * @return
     */
    private Map<Emotion, Double> determineEmotionByNeighbourhood(FeatureNode bmu) {
        // Map holding the fitness values of each
        Map<Emotion, Double> fitnessValues = new HashMap<Emotion, Double>();
        Double largestValue = Double.MIN_VALUE;

        for (int i = 0; i < featureMap.getMapSize(); i++) {
            for (int j = 0; j < featureMap.getMapSize(); j++) {
                Map<String, Integer> values = featureMap.nodes[i][j].getValues();
                for (String entry : values.keySet()) {
                    Emotion emotion = Emotion.getEmotionByString(entry);
                    // Ignore other testing photos, only use for training photos.
                    if (emotion != Emotion.NONE) {
                        double fitnessValue = values.get(entry)
                                / (1.0 + Math.abs(bmu.getPositionX() - i) + Math.abs(bmu
                                        .getPositionY() - j));

                        double previousFitness = 0;
                        if (fitnessValues.containsKey(emotion)) {
                            previousFitness = fitnessValues.get(emotion);
                        }

                        System.out.println("DEBUG: " + fitnessValue + " " + previousFitness);
                        double total = Math.pow(fitnessValue, 2) + previousFitness;
                        fitnessValues.put(emotion, total);

                        if (total > largestValue) {
                            largestValue = total;
                        }
                    }
                }

            }
        }

        // Normalize fitness values.
        for (Emotion entry : fitnessValues.keySet()) {
            fitnessValues.put(entry, fitnessValues.get(entry) / largestValue);
        }

        return fitnessValues;
    }

    private void classifyOneInput(List<Double> input, String category) {
        FeatureNode minNode = null;
        double influence, currentDistanceSquared = 0;

        minNode = getBMU(input, category);

        // Now we have BMU, adjust weights of neighbourhood.
        for (int i = 0; i < featureMap.getMapSize(); i++) {
            for (int j = 0; j < featureMap.getMapSize(); j++) {
                currentDistanceSquared = SomMath.calculateDistanceSquared(minNode,
                        featureMap.nodes[i][j]);

                // If node is within the neighbourhood.
                if (currentDistanceSquared < Math.pow(featureMap.getRadius(), 2)) {

                    // Adjust weights
                    influence = SomMath.calculateInfluence(currentDistanceSquared,
                            featureMap.getRadius());
                    for (int k = 0; k < input.size(); k++) {
                        featureMap.nodes[i][j]
                                .setWeight(SomMath.calculateNewWeight(input.get(k),
                                        featureMap.nodes[i][j].getWeights()[k], learningRate,
                                        influence), k);
                    }
                }
            }
        }
    }

    public FeatureMap getFeatureMap() {
        return featureMap;
    }

    //
    // Builders below
    //

    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {
        private int inputSize;
        private double learningRate = 0.25d;
        private int mapSize;
        private int maxEpochs = 1000;
        private double radius = 0;

        public Builder setInputSize(int inputSize) {
            this.inputSize = inputSize;
            return this;
        }

        public Builder setLatticeSize(int mapSize) {
            if (this.radius == 0) {
                this.radius = mapSize / 2;
            }
            this.mapSize = mapSize;
            return this;
        }

        /**
         * Optional.
         */
        public Builder setRadius(double radius) {
            this.radius = radius;
            return this;
        }

        /**
         * Optional.
         */
        public Builder setLearningRate(double learningRate) {
            this.learningRate = learningRate;
            return this;
        }

        /**
         * Optional.
         */
        public Builder setMaxEpochs(int maxEpochs) {
            this.maxEpochs = maxEpochs;
            return this;
        }

        public SOM buildClassifier() {
            return build();
        }

        private SOM build() {
            return new SOM(inputSize, mapSize, learningRate, maxEpochs, radius);
        }
    }

    public void debugPrint() {
        StringBuilder s = new StringBuilder();

        s.append("/------------------------------------------- SOM ------------------------------------------\\\n");

        s.append("|---------------------------------- Neural network view -----------------------------------|\n");

        for (int i = 0; i < featureMap.getMapSize(); i++) {
            for (int j = 0; j < featureMap.getMapSize(); j++) {
                s.append("|");
                String data = "";
                // DecimalFormat df = new DecimalFormat("#.##");
                // for (Double weight: featureMap.nodes[i][j].getWeights()) {
                // data += df.format(weight) + " ";
                // }
                Map<String, Integer> values = featureMap.nodes[i][j].getValues();
                for (String category : values.keySet()) {
                    data += category + values.get(category);
                }
                s.append(data);
                for (int k = 0; k < 10 - data.length(); ++k) {
                    s.append(" ");
                }
            }
            s.append("| \n \n");
        }

        s.append("/------------------------------------------- SOM ------------------------------------------\\\n");

        System.out.println(s);
    }

}
